Skip to content

feat(pi): renew Pi credentials on the host before they expire - #251

Merged
ruby-dlee merged 3 commits into
mainfrom
fm/pi-auth-refresh
Aug 19, 2026
Merged

feat(pi): renew Pi credentials on the host before they expire#251
ruby-dlee merged 3 commits into
mainfrom
fm/pi-auth-refresh

Conversation

@ruby-dlee

Copy link
Copy Markdown
Owner

Why

Every Pi profile in the fleet expired on 2026-08-25 and nothing renewed one. bin/fm-credential-expiry.py is the detector and stops at the actuator by design, so a profile classified refreshable stayed refreshable until a human opened a browser.

A reviewer compartment cannot renew its own credential: its egress allowlist carries the provider's API host and deliberately not the provider's auth host, so a CLI inside the compartment can never reach a token endpoint. Renewal has to happen on the host and be staged outward.

What lands

bin/fm-pi-refresh.py selects the profiles whose access token dies inside a horizon, copies the pool, hands the due slots to bin/fm-pi-refresh.mjs, republishes each renewed slot into the account home its consumers read, and re-reads that home through the expiry owner before reporting success. Its exit code means the fleet is live, not that an HTTP call returned 200.

Why the rotation is in Node

Only half of the rotation is the HTTP request. The other half is the write-back, which must land under the lock Pi takes or a running Pi overwrites it, and the refresh has to happen inside that same lock or two refreshers spend one refresh token and the provider invalidates the loser. Driving Pi's own AuthStorage uses that protocol rather than re-deriving it in another language. The prior art at ~/.pi/agent/fm-patches/reauth.sh writes the pool without the lock, and its own header warns about exactly this.

Both modules are imported by absolute path because Pi's exports map publishes neither, and the package barrel does not export AuthStorage and pulls in the whole TUI.

Decisions worth reviewing

  • The horizon defaults to half the observed ten-day credential life, so the machine has to be off five consecutive days before a token is lost.
  • The copy is taken before anything rotates, because Pi rewrites the credential file with a truncating write rather than a rename. An interrupted write loses every slot in the file, not the one being renewed.
  • A Node below the floor the Pi install declares is refused by version. This machine carries two Pi installs, and the older one's sibling Node dies inside undici with a message that reads like a provider failure.
  • A provider error is redacted before it is reported. Pi's refresh error text interpolates the provider's JSON response, and one failure shape of that response carries an access token.
  • A rotation is decided by a changed access-token digest, not by the store returning a credential: modify returns the stored credential unchanged when its callback declines, which is otherwise indistinguishable from success.
  • Only account homes that already exist are republished. The reviewer roster names homes by path, so a home appearing on its own is a reviewer nobody added.

bin/fm-credential-expiry.py's docstring and two of its detail strings said firstmate has no token refresh anywhere. That is now false for Pi, so they say what is still true: refreshable still means not usable where the caller runs, and a caller that needs a live credential asks for usable.

Verification

Run against the real fleet: all eight profiles renewed from 2026-08-25 to 2026-08-29, republished, and read back through bin/fm-credential-expiry.py (eight usable) and through bin/fm-crosscheck.py's own inspect_pi_credential and account_identity (eight distinct accounts, unchanged).

tests/fm-pi-refresh.test.sh (hermetic, registered in both inventories, run through tests/run.sh) drives the adapter against Pi's real credential store with only the rotation stubbed, so the lock, the read-modify-write and the on-disk result are the production mechanism rather than a description of it. It asserts the rotated case, the unrotated case, an absent slot, an account that drifts, and a throwing refresh whose token-shaped text must come back redacted. The CLI half runs against a fixture Pi install and a stubbed Node, and proves that report never invokes the adapter at all.

The test found two defects before this landed: a second-resolution backup name that collided into an unhandled traceback, and a missing OSError refusal contract.

bin/fm-behavior-shards.sh --check 8 passes. tests/fm-credential-expiry.test.sh and tests/fm-pi-account-home.test.sh still pass.

Not in this change

The LaunchAgent that makes this unattended is a separate change, so this one stays reviewable on its own.

Every Pi profile in the fleet expired on the same day and nothing renewed one.
fm-credential-expiry.py is the detector and stops at the actuator by design, so
a profile classified refreshable stayed refreshable until a human opened a
browser. A reviewer compartment cannot renew its own credential either: its
egress allowlist carries the provider's API host and deliberately not the
provider's auth host. Renewal has to happen on the host and be staged outward.

fm-pi-refresh.py selects the profiles whose access token dies inside a horizon,
copies the pool, hands the due slots to fm-pi-refresh.mjs, republishes each
renewed slot into the account home its consumers read, and re-reads that home
through the expiry owner before reporting success. Its exit code therefore
means the fleet is live, not that an HTTP call returned 200.

The rotation runs in Node because only half of it is the HTTP request. The
other half is the write-back, which must land under the lock Pi takes or a
running Pi overwrites it, and because the refresh has to happen inside that
same lock or two refreshers spend one refresh token and the provider
invalidates the loser. Driving Pi's own AuthStorage uses that protocol instead
of re-deriving it. The prior art at ~/.pi/agent/fm-patches/reauth.sh writes the
pool without the lock and its own header warns about exactly this.

Details worth knowing:

- The horizon defaults to half the observed ten-day credential life, so the
  machine has to be off five consecutive days before a token is lost.
- The copy is taken before anything rotates because Pi rewrites the credential
  file with a truncating write rather than a rename, so an interrupted write
  loses every slot in the file rather than the one being renewed.
- A Node below the floor the Pi install declares is refused by version. This
  machine carries two Pi installs and the older one's sibling Node dies inside
  undici with a message that reads like a provider failure.
- A provider error is redacted before it is reported. Pi's refresh error text
  interpolates the provider's JSON response, and one failure shape of that
  response carries an access token.
- A rotation is decided by a changed access-token digest, not by the store
  returning a credential: modify returns the stored credential unchanged when
  its callback declines, which is indistinguishable from success otherwise.

fm-credential-expiry.py's docstring and two of its detail strings said
firstmate has no token refresh anywhere. That is now false for Pi, so they say
what is still true instead: refreshable still means not usable where the caller
runs, and a caller that needs a live credential asks for usable.

Verified on the real fleet: all eight profiles renewed from 2026-08-25 to
2026-08-29, republished, and read back through fm-credential-expiry.py and
through fm-crosscheck.py's own reader and identity, still eight distinct
accounts. The behavior test drives the adapter against Pi's real credential
store with only the rotation stubbed, so the lock, the read-modify-write and
the on-disk result are the production mechanism rather than a description of
it. It found two defects before this landed: a second-resolution backup name
that collided into an unhandled traceback, and a missing OSError refusal
contract.

Size note: about 1300 added lines across an entrypoint, an actuator and a
behavior test. The scheduler that makes this unattended is a separate change.
Dongkeun Lee added 2 commits August 19, 2026 00:28
… defects

An adversarial review found that the single most valuable test in this change
did not run on the machine that gates merges. The adapter contract skipped
whenever Pi was absent, Pi is never installed on the CI runner, and the run
still exited 0. Two mutations of the actuator, the rotation decision and the
token redaction, passed CI green. Confirmed in the job log for f991c62:
"skip: pi is not installed for the adapter contract", exit=0.

The test is now two units. The outcome contract drives the real refreshSlots
with a store that keeps modify's contract and needs nothing but Node, because
what it pins is the adapter's own classification. The store contract drives
Pi's real credential store and covers the lock, the read-modify-write and what
lands on disk. CI installs Pi and sets FM_PI_REQUIRED, so a failed install is a
red test rather than a silent skip. Verified with Pi removed from PATH
entirely: three mutations, including both that used to survive, now go red.

bin/fm-lint-node.sh parses the .mjs and .cjs tools in bin/. ShellCheck's file
set is shell only, so a syntax error in the repo's JavaScript tools reached
main with a green lint.

Five defects the same review found and demonstrated:

A held lock made a live credential report as absent. AuthStorage.read gives up
after Pi's 30s lock deadline and swallows the failure over an empty snapshot,
so a slot merely held by a running Pi read as having no credential at all. The
optimistic read is gone; everything the outcome depends on is now read inside
the lock, which also removes the read-to-modify window.

A rotation the provider made but the store could not keep was reported as an
ordinary failure. It is not: the host is left holding a token the provider has
retired, and restoring the pre-renewal copy restores that same dead token. It
now reports rotated-unpersisted and says the profile needs an interactive
login.

The adapter time budget counted only the HTTPS round trip, so eight slots got
190s against a 400s worst case, and an overrun SIGKILLed Node possibly mid
write. That is the interrupted write the copy exists to survive, self
inflicted. The budget now includes Pi's 30s lock deadline per slot.

Backups were pruned only on the success path, so a recurring failure left one
more full copy of every credential in the fleet at rest per scheduled run. They
are pruned as soon as the pool is proved intact.

The Codex flow was applied to any slot handed to it. The only thing stopping an
Anthropic credential's refresh token from being posted to OpenAI's token
endpoint was that Anthropic credentials carry no accountId and a different
tool happened to require one. That is now a gate in the adapter with its own
outcome.

Also: Python redacts adapter stderr on its own side of the process boundary
rather than trusting a slice; the pre-renewal copy is proved to be a copy
before it is offered as one; each candidate Node is probed once rather than
twice, so a hanging Node costs one timeout; the Pi package is identified by
name rather than by having any package.json; FM_PI_BIN naming a file without
the execute bit says so instead of "not on PATH"; and --all together with
--slot refuses instead of silently ignoring the slots.
Every behavior shard failed on the previous head, and the install was not the
reason: the step that verified it was. `require("@earendil-works/...")` does
not resolve from npm's global prefix, so the verification threw and took the
whole job with it before a single test ran.

It now resolves the package by absolute path from `npm root -g`, asserts the
one module the adapter actually imports is there, and exports
FM_PI_PACKAGE_DIR so the store contract finds it without a PATH lookup for a
binary it never runs.

Proved locally by running the same resolution and then running the suite with
Pi removed from PATH entirely, reaching it only through FM_PI_PACKAGE_DIR.
@ruby-dlee
ruby-dlee merged commit de7a904 into main Aug 19, 2026
13 checks passed
@ruby-dlee
ruby-dlee deleted the fm/pi-auth-refresh branch August 19, 2026 05:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant